Skip to content

Retention: perform the deletions the privacy policy promises - #69

Merged
robbyrob42 merged 4 commits into
primaryfrom
rob/retention-purge
Sep 3, 2026
Merged

Retention: perform the deletions the privacy policy promises#69
robbyrob42 merged 4 commits into
primaryfrom
rob/retention-purge

Conversation

@robbyrob42

Copy link
Copy Markdown
Collaborator

Closes #67.

What

workers/retention — a cron-only Worker performing the three deletions the live policy commits to, plus migration 0004 for the run log, plus a CI job to deploy it.

Stacked conceptually on #68 (migration tracking); 0004 is the first migration that will land through wrangler d1 migrations apply.

Why a separate Worker

It deletes data. A fault in it must not be able to take down a request path. It also has no fetch handler at all, so the cron is the only way in and there is nothing to authenticate or reach.

The three tasks

Task Statement Policy clause
intake_summary UPDATE case_intake SET matter_summary = '' at 90 days matter description, unconditional
contact_submissions DELETE at 12 months contact enquiries
intake_spam DELETE where spam_reason IS NOT NULL at 12 months spam-judged submissions

The 90-day purge blanks a column and never deletes a row. The conflict record — who approached the practice, on which side, when — is in the same row and is kept for as long as the practice operates. A DELETE there would quietly destroy what the conflict screen runs against. Spam rows are the one exception, because a submission judged automated carries no conflict record worth keeping.

Cutoffs use SQLite's datetime('now', ...) rather than a Date built in the Worker, so the comparison uses the same clock that wrote created_at.

Evidence

retention_runs gets a row per task per run even when nothing changed. A zero proves the job ran and found nothing due; a missing row proves it did not run. Counts only — recording which rows lost a description would rebuild a shadow of the thing being deleted.

Verified locally

Seeded database, wrangler dev --test-scheduled:

retention: intake_summary ...: 2 rows
retention: contact_submissions ...: 1 rows
retention: intake_spam ...: 1 rows
retention: intake_summary ...: 0 rows      <- second run, idempotent
retention: contact_submissions ...: 0 rows
retention: intake_spam ...: 0 rows

Final state: the 100-day matter kept its row with an empty description, the 10-day matter was untouched, the 400-day spam and 400-day contact rows were gone, and the 10-day equivalents survived.

One thing for you to decide

The policy says contact enquiries are deleted at twelve months "unless they relate to a live matter or engagement." That exception has exactly the problem you spotted in the intake clause — nothing in this database can evaluate it, so the Worker deletes unconditionally and the policy overstates what happens. Either drop the clause (one-line edit, consistent with the decision you already made) or tell me what signal should protect a row.

🤖 Generated with Claude Code

https://claude.ai/code/session_01J53xszP5CdZLXfhRJWsSyv

robbyrob42 and others added 4 commits September 3, 2026 15:09
The policy went live on rootsystem.com committing to three deletions and
nothing performed any of them. This is the Worker that does.

A separate Worker rather than a cron on the forensics site. This one
deletes data, and a fault in it must not be able to take down a request
path; it also has no fetch handler at all, so the cron is the only way
in and there is nothing to authenticate.

The three tasks match the policy clause for clause. The 90-day intake
purge blanks `matter_summary` and never deletes the row -- the conflict
record is in the same row and is kept for as long as the practice
operates, so a DELETE would quietly destroy what the conflict screen runs
against. Spam-judged intake rows are the one case that is deleted
outright: a submission judged automated carries no conflict record worth
keeping. Contact enquiries go at twelve months.

Cutoffs are evaluated by SQLite's own `datetime('now', ...)` rather than a
Date built in the Worker, so the comparison uses the same clock that
wrote `created_at` and no timezone question arises. The intake purge is
guarded on `matter_summary <> ''` so that already-purged rows are not
recounted as affected on every subsequent run, which would make the log
meaningless.

Migration 0004 adds `retention_runs`. Every task writes a row even when
it changed nothing, because a zero and a missing row are different facts:
one proves the job ran and found nothing due, the other proves it did not
run. It records counts only -- logging which rows lost a description, let
alone what they said, would rebuild a shadow of the thing being deleted.

Tasks are independent. One failing does not skip the others, since a
transient error on one statement is no reason to defer a deletion that is
due today.

Verified against a seeded local database: a 100-day-old matter had its
description blanked and its row kept, a 10-day-old one was untouched, a
400-day-old spam row and a 400-day-old contact enquiry were deleted, and
10-day-old equivalents survived. A second run recorded zeros for all
three tasks, which is the idempotency check.

CI deploys it from primary only; a pull request gets `--dry-run`, because
a version upload of a cron-only Worker produces nothing to look at while
a dry run still catches config and bundling errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J53xszP5CdZLXfhRJWsSyv
Adding `workers/*` to the workspace globs changes resolution, and CI
installs with `--immutable`, so the lockfile has to move with it. The only
addition is `@cloudflare/workers-types`, which is a type-only dependency:
wrangler strips types when it bundles, so nothing ships from it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J53xszP5CdZLXfhRJWsSyv
It read "deleted twelve months after they were received, unless they
relate to a live matter or engagement". Nothing in this database can
evaluate that condition -- the same flaw the intake clause had, and the
same answer: the exception goes rather than the schema growing to support
it.

Both deletions are now unconditional, which is the only shape a
commitment can take when the system making it cannot tell the exception
apart from the rule. It also makes the policy match what
workers/retention actually does, which it did not before this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J53xszP5CdZLXfhRJWsSyv
* origin/primary:
  chore(db): adopt wrangler migration tracking after the fact
@robbyrob42
robbyrob42 merged commit 6edb2f4 into primary Sep 3, 2026
4 checks passed
@robbyrob42
robbyrob42 deleted the rob/retention-purge branch September 3, 2026 22:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Retention: build the unconditional 90-day purge the privacy policy commits to

1 participant